データ取得から見るGraphQL、Concurrent Mode、Server Components
tl;dr
とりあえずReact公式ドキュメントにまとめられているデータ取得方式をまとめてみます 例として、こういう感じのAPIがあって
code:api.ts
// ブログ記事みたいなもの
type Story = {
title: string
body: string
// 関連記事一覧
relatedStoryIds: string[]
}
function fetchStory(id: string): Promise<Story> { ... }
こういう感じのPresenter Componentが定義されているとします
code:presenters.tsx
// 記事詳細ページ
const StoryDetails: React.VFC<{
title: string
body: string
relatedStoryItems: React.ReactNode
}> = ...
const StoryDetailsPlaceholder: React.VFC = ...
// 記事のリッチなリンクみたいなもの
const StoryItem: React.VFC<{ title: string }> = ...
const StoryItemPlaceholder: React.VFC = ...
いつもuseEffectでやってるような、データが必要になったらその場でデータ取得を始める方法です。
swr や react-query のようなデータ取得ライブラリもこれに含まれます
データ競合はとりあえず考えないことにして、例を書いてみます。
code:containers-fetch-on-render.tsx
const StoryItemContainer: React.VFC<{ id: string }> = ({ id }) => {
useEffect(() => {
fetchStory(id).then(setStory)
if(story == null) return <StoryItemPlaceholder />
return <StoryItem title={story.title} />
}
const StoryDetailsContainer: React.VFC<{ id: string }> = ({ id }) => {
// StoryItemContainerと同じなので省略
if(story == null) return <StoryPlaceholder />
return (
<StoryDetails
title={story.title}
body={story.body}
relatedStoryItems={
story.relatedStoryIds.map(
storyId => (
<StoryItemContainer id={storyId} />
)
)
}
/>
)
}
この方法は「コンポーネントが必要なデータを得たらすぐに描画できる」というところが良いです。待たされる時間が最小限で済みます。逆に「コンポーネント階層がデータの取得順を決めてしまっている」という欠点があります。これだけだと謎なので例を挙げてみます。
さっきの例が、仕様変更によって<StoryDetails />が関連記事一覧をファーストビューに置くことになったとします。
この実装だと記事の取得が完了した後にすぐに記事の描画が行われます。結果としてユーザーは必ず関連記事一覧のプレースホルダを目にすることになります。体験が悪い。
これを避けるために記事の描画タイミングを関連記事一覧の取得タイミングに揃えようと思います。つまりコンポーネント階層を変えて、<StoryDetailsContainer/>が<StoryItem/>を直接描画するように変更します。
code:containers-fetch-on-render-2.tsx
const StoryDetailsContainer: React.VFC<{ id: string }> = ({ id }) => {
useEffect(() => {
async function fetchData() {
setStory(await fetchStory(id))
setRelatedStories(
await Promise.all(
storyData.relatedStoryIds.map(fetchStory)
)
)
}
fetchData()
if(story == null || relatedStories == null) return <StoryPlaceholder />
return (
<StoryDetails
title={story.title}
body={story.body}
relatedStoryItems={
relatedStories.map(
s => (
<StoryItem title={s.title} />
)
)
}
/>
)
}
見比べてみるとわかりますが、かなり大きく変更されたことがわかります。つらい。
ここにさらに「ファーストビューでの関連記事は最初の5個しかいらない」というような要件が入ったらどうなるでしょう?考えたくもないですね。
Fetch-on-Renderで起きた問題は、ページ上位でデータを全て取得してしまうことで緩和することができます。つまり、「データ取得順によって問題が起きるなら、全部一気に取得してしまえ!」ということです。イメージとしてはNext.jsが近いです。 code:containers-fetch-then-render.tsx
type StoryDetailsData = {
story: Story
relatedStories: Story[]
}
// ルータに呼ばれるデータ取得関数
async function fetchStoryDetails(id: string): Promise<StoryDetailsData> {
const story = await fetchStory(id);
const relatedStories = await Promise.all(
story.relatedStoryIds.map(fetchStory)
)
return {
story,
relatedStories
}
}
// ルータがdataを渡す
const StoryDetailsContainer: React.VFC<{ data?: StoryDetailsData }> = ({ data }) => {
if (data == null) return <StoryDetails />
const { story, relatedStories } = data
return (
<StoryDetails
title={story.title}
body={story.body}
relatedStoryItems={
relatedStories.map(
s => (
<StoryItem title={s.title} />
)
)
}
/>
)
}
この方法にはデータの依存関係の問題の解決以外にも、データ取得の責任がルータに移ったことでルータが柔軟な制御ができるという利点があります。つまり
Next.jsが既にやっているように、データ取得が完了するまでページ遷移を遅らせるということができます しかし
必要なデータをまとめてクエリするのは難しいです
要求しているデータが来るまで描画を開始できず、場合によってはFetch-on-Renderを併用する必要が出てきます。結局データの依存関係を考える必要がありそうです 細かい単位で表示順序を制御できて
データの依存関係とコンポーネント階層を分離できる
code:resource.ts
interface Resource<T> extends Promise<T> {
read(): T
then<U>(f: (value: T) => U | Promise<U>): Resource<U>
}
function Resource<T>(create: () => Promise<T>): Resource<T> { ... }
code:containers-render-as-you-fetch.tsx
const StoryItemContainer: React.VFC<{ story: Resource<Story> }> = (props) => {
const story = props.story.read()
return <StoryItem title={story.title} />
}
type StoryDetailsData = {
story: Story
relatedStories: Resource<Story>[]
}
function fetchStoryDetails({ id }: { id: string }): Resource<StoryDetailsData> {
return Resource(() => fetchStory(id)).then(
story => ({
story,
relatedStories: story.relatedStoryIds.map(
storyId => Resource(() => fetchStory(storyId))
)
})
)
}
const StoryDetailsContainer: React.VFC<{ data: Resource<StoryDetailsData> }> = ({ data }) => {
const { story, relatedStories } = data.read()
return (
<StoryDetails
title={story.title}
body={story.body}
relatedStoryItems={
relatedStories.map(
s => (
<Suspense fallback={<StoryItemPlaceholder />}>
<StoryItemContainer story={s} />
</Suspense>
)
)
}
/>
)
何が起きてるのか完全に意味不明だと思います。PromiseじゃなくてResourceとかいうよくわからないものが使われてますし。
ResourceはPromiseに状態管理を付けたものです。状態管理と言ってもReduxやRecoilのような複雑なものではなく、Promiseの内部状態を管理して取得可能な場合に値を同期的に取得できるようにするものです。 .read()すると内部のPromiseが
待機状態(pending)な場合、コンポーネントの最も近い祖先の<Suspense />がフォールバックを表示し、待機状態が終わったときに再描画を試みます
完了状態(fulfilled)な場合、値を返します
拒絶状態(rejected)な場合、エラーをthrowします
ここで、Fetch-on-Renderのときのような仕様変更を考えます。記事の表示タイミングと関連記事一覧の表示タイミングを揃えたいわけですが、実は<Suepense />を外すだけです。 code:containers-render-as-you-fetch-2.tsx
...
relatedStoryItems={
relatedStories.map(
s => (
<StoryItemContainer story={s} /> // <-ここの Suspense を外しただけ
)
)
}
...
調子に乗って関連記事の最初5個だけを記事と揃えるようにしてみましょう。
code:containers-render-as-you-fetch-3.tsx
...
relatedStoryItems={
relatedStories.map(
(s, i) => {
const node = <StoryItemContainer story={s} />
if(i < 5) return node // 最初5個は Suspense なしで描画
return <Suspense fallback={<StoryItemPlaceholder/>}>{node}</Suspense>
}
)
}
...
非常に簡単にできてしまいました。また、次々に記事が表示されると見た目が悪いので、残りは取得が全部終わったら表示するようにしましょう。
code:containers-render-as-you-fetch-4.tsx
...
relatedStoryItems={
<SuspenseList revealOrder="together">
{relatedStories.map(
(s, i) => {
const node = <StoryItemContainer story={s} />
if(i < 5) return node // 最初5個は Suspense なしで描画
return <Suspense fallback={<StoryItemPlaceholder/>}>{node}</Suspense>
}
)}
</SuspenseList>
}
...
はい。<SuspenseList />を追加しただけです。<SuspenseList />は<Suspense />をまとめて制御するプリミティブです。revealOrder="togetherの場合、配下の<Suspense />全てが完了状態になるまで全てを待機状態にします。ここでは細かい説明をしないので、公式ドキュメントを見ることをおすすめします。 このように、<Suspense />と<SuspenseList />を組み合わせるだけでどのようにデータを待つのか決めることができてしまいます。
TODO: 例
どのようにデータをまとめて取得するか
使うデータが多くなってくると、それらを全部まとめるのは難しくなる
データ同士に依存関係がある(relatedStoryIdsのような)ケースだと、ラウンドトリップが気になる
このような問題を解決する手段として有名なものにGraphQLがあります。Fragmentを使ってコンポーネントの要求するデータを表現し、それをまとめて1つのQueryを作ることができます。データの依存関係はサーバ上で解決されるので、ラウンドトリップの心配もありません。 クエリを分割して実行することである程度対処できますが、クエリが複雑な場合分割は面倒になりますし、複数のクエリを実行するのはサーバ負荷の上昇も気になります。この問題を解決するために、@deferや@streamのようなディレクティブを使い、サーバにレスポンスを分割して返すことを許すようにするという仕様が提案され、実装が始まっています。
ラウンドトリップの問題に対処する手段として、既にBFFを使うという方法が知られています。つまりクライアントと(リソースを持っている)サーバの間にもう1つサーバを置いて、その上でデータの依存関係を解決してしまおうという方法です。 確かにBFFで問題を解決することはできます。しかしコンポーネントの要求するデータをうまく表現し、まとめるのは難しそうです。クライエントを変更する度にBFFを変更してデータの依存関係の解決や、GraphQLクエリの分解に相当することをやる必要が出てきます。最終的に面倒になって大きく安全側に倒したBFFができそうです。
ここで重要なことは、コンポーネントは常に自分がどんなデータを必要としているかを知っているということです。つまり、変更にBFFを追従させるには、どんなデータが必要か、更新対象のコンポーネントに尋ねればよいわけです。